iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Software Development

ERP 架構師筆記:定義驅動的框架設計系列 第 23

Day 23:API Payload 安全管線:順序、保護等級與金鑰

  • 分享至 

  • xImage
  •  

Day 23:API Payload 安全管線:順序、保護等級與金鑰

昨天結尾說今天看 value 上的另一半。EncodedEncrypted 在 API payload 上只差一個數字,底下卻是三段有先後的處理,而回程走的時候,順序不只是倒著跑一遍。

一套 ERP 的 API 要回答兩個彼此獨立的問題:一次呼叫的內容該處理成什麼樣子,以及誰有資格決定它可以少處理一點。前者是一條固定的管線,後者是一句寫在方法上的宣告。兩件事分開放,只在存取控制那一站被比在一起。

本篇說明:

  1. 序列化、壓縮、加密三段的順序,以及調換之後會壞在哪裡
  2. ApiProtectionLevelPayloadFormat 是兩個不同的東西,存取控制怎麼把它們比在一起
  3. aes-cbc-hmac 這個名字底下實際做了哪幾件事
  4. 金鑰從哪裡來,以及密碼學原語與安全政策為什麼分居兩層

一、三段的順序

Encodedvalue 送出去是一段 Base64。從一個物件走到那段 Base64,中間發生三段處理,每一段各由一個介面承擔,也各自可以整個換掉:

做什麼 現行實作 還能換成什麼
序列化 物件變成 bytes messagepack json
壓縮 bytes 變短 gzip none
加密 bytes 變成帶認證碼的 bytes aes-cbc-hmac none

後兩個名字寫在部署的那份 SystemSettings 裡,第一個由呼叫端逐次宣告在 API payload 上,沒有宣告就是 messagepack,昨天那張「三個組件,兩種決定方式」的表講的就是這件事。可以換掉的是每一段的實作,不是它們的先後。編碼那一支(ApiPayloadTransformer)只有兩行,原始碼把順序寫在行末:

byte[] bytes = serializer.Serialize(payload, type);              // Serialize
return ApiServiceOptions.PayloadCompressor.Compress(bytes);      // Compress

加密不在這兩行裡。它是外面那一層依格式決定要不要多做的一步,Encoded 停在這裡,Encrypted 再多走一段。

壓縮之所以排在加密之前,是因為壓縮對密文完全無效。一份良好加密的輸出在統計上與隨機資料沒有差別,而壓縮演算法找的正是重複與偏態。拿案例那份訂單的 FormSchema 跑一次就看得出來:

順序 結果
序列化 → 壓縮 → 加密 壓完剩下四分之一出頭,加密再多加幾十個位元組
序列化 → 加密 → 壓縮 壓完比壓之前還大,最後的體積是前者的三倍以上

把順序調換不會讓任何一次呼叫失敗,也不會有任何一道檢查出聲。它只是讓壓縮那一段從此不做事。

回程的順序是反過來的三步:解密、解壓縮、反序列化。但反向真正的重點不在「倒著跑」,在於每一段只看得到通過上一段的 bytes。認證碼的驗證發生在解密之前,解密又在解壓縮之前,所以那個 gzip 解壓器從頭到尾拿不到一段沒有驗過的資料。

解壓器是一個吃任意 bytes 的解析器,把一段改過一個位元的密文直接餵給它,它會照樣開始解析,然後因為讀不出結構丟出例外。這條順序讓它沒有機會走到那裡。


二、宣告的等級,與實際用的格式

PayloadFormat 的三個值昨天講過。存取控制那一站要看的不是它一個,是它跟另一個 enum 比出來的結果,而那個 enum 有四個值。

回答的問題 幾個值 誰決定
PayloadFormat 這一次呼叫實際做了什麼 三個 呼叫端,寫在 API payload 上
ApiProtectionLevel 這一支方法最低接受什麼 四個 寫這支方法的人,掛在方法或型別上

兩個 enum 有兩個值同名(EncodedEncrypted),很容易被當成同一件事的兩種說法。它們不是:一個記錄事實,一個宣告要求。

四個等級各要求什麼,適合放哪一種方法:

等級 意思 可以用在哪
Public HTTPS 就夠了 內容被伺服器看到也無妨的呼叫,例如連通性探測 Ping
Encoded 至少要編碼過 不想讓人抓包就直接讀出來,但不值得付加密成本的
Encrypted 要加密過 內容在伺服器裡也不該是明文的,例如 API 金鑰的管理
LocalOnly 不接受遠端呼叫 只該由主機行程自己發起的,例如寫入定義

框架自己的方法怎麼分布在這四級上,本身是一個決定:把信任界線畫在 HTTPS 上,只有寫方法的人明確判斷「進了伺服器之後也不能被看到」的那幾支才標 Encrypted。中間那一級現行沒有人宣告,那是格式那一側的落點,不是等級這一側的要求。

畫在 HTTPS 上不代表應用層加密沒有用。HTTPS 只保護資料在網路上跑的那一段,一進到伺服器就解開了,後面經手的每一站看到的都是明文:節點被入侵、記錄與監控把內容留了下來、跨網段或 VPN 上被攔下來重放,這些在 ERP 的實際部署裡都是真的。

線畫在 Public,換到的是沒有 .NET 執行環境的前端不必先實作整條加密管線才呼叫得到第一支方法;代價是走這一級的呼叫,value 進了伺服器之後就看得見,需要那一層的方法得自己標上去。

判準是:實際的格式要大於或等於宣告的等級,多做無妨,少做拒絕。下面這張矩陣講的都是遠端呼叫,而遠端呼叫不管格式做到哪一級,都過不了 LocalOnly

實際格式 \ 宣告等級 Public Encoded Encrypted LocalOnly
Plain 通過 拒絕 拒絕 拒絕
Encoded 通過 通過 拒絕 拒絕
Encrypted 通過 通過 通過 拒絕

對著案例的端點打,三種拒絕各帶各的訊息:

Plain   呼叫 System.ListApiKeys → This API requires encoded or encrypted transmission.
Encoded 呼叫 System.ListApiKeys → This API requires encrypted transmission.
Plain   呼叫 System.SaveDefine  → This API is restricted to local calls only.

「多做無妨」那一半同樣實測過:拿一個 Encoded 的 API payload 呼叫 Public 的清單查詢,通過,而且回來的也是 Encoded。伺服端不動格式,它照著呼叫端送來的那個值把回應包回去,所以一次往返的兩個方向永遠是同一個格式。

LocalOnly 不是「更嚴格的加密」,它問的是另一件事:這支方法根本不從網路進來。既然不在同一條軸上,它就不跟格式比大小,是單獨判的。Day 17 說過伺服端有幾支方法只接受同行程呼叫,指的就是這一格。它們仍然叫得動,只是不從網路叫。近端呼叫一進這道檢查就被整個放行,連格式都不看,上面那張矩陣管不到它們。

這道檢查能排在解密之前,是因為它要比的兩個值一開始就拿得到:格式是 API payload 上的一個數字,等級掛在型別上、編譯完就固定了。兩邊都不必拆開 value 看一眼。Day 16 說過存取控制排在還原之前、不替沒通過的請求做解密工作,成立的條件就在這裡。

沒有宣告的方法不是「不受限制」,是直接擲例外。找的順序是方法自己、它覆寫的那一支、再到宣告它的型別。代價是漏掉宣告要等有人第一次呼叫才會發現,所以建置期另有一條規則會把這種方法指出來。


三、aes-cbc-hmac 這個名字底下有幾件事

這個名字把三個部分寫在一起:一個對稱加密演算法、一個工作模式、一個訊息認證碼。實作上是四個決定,加解密的主體是 AesCbcHmacCryptor

  • 傳進去的是一把 64 個位元組的金鑰,切成兩把用:前 32 個當 AES 的金鑰、後 32 個當 HMAC 的金鑰。同一把金鑰不兼兩職。
  • 每一次加密現場產生新的 IV,本身以明文寫進輸出(解密要用它)。實測同一份資料、同一把金鑰連續加密兩次,出來的 bytes 不一樣。
  • 先加密,再對加密的結果蓋認證碼,不是反過來。換到的是解密端在解密之前就把資料擋掉,反過來做的版本得先解密才驗得了,等於讓解密器先吃一段來路不明的資料。第一節那條「每一段只看得到通過上一段的 bytes」,起點就在這裡。
  • 驗證認證碼用固定時間的比對,不是 ==。一般的迴圈會在第一個不同的位元組就回傳,於是「猜對了幾個位元組」變成從外面量得出來的事,攻擊者可以一個位元組一個位元組把認證碼湊出來。

固定時間比對那一條在解密那一段就是三行:

byte[] computedHmac = hmac.ComputeHash(dataToVerify);
if (!CryptographicOperations.FixedTimeEquals(hmacBytes, computedHmac))
    throw new CryptographicException("HMAC validation failed.");

訊息本身也藏著一個選擇。實測三種不同的破壞方式:改一個位元的密文、改一個位元的認證碼、換一把完全無關的金鑰,回來的都是同一則 HMAC validation failed.。分不出來是對的,能分辨「認證碼不對」與「金鑰不對」本身就是一個可以拿來試探的訊號。


四、金鑰從哪裡來

上一節那四個決定全部做對,還是沒有回答一件事:那把 64 個位元組的金鑰是誰給的。框架把這件事跟上一節分在兩層:

位置 放什麼 舉例
密碼學原語 Bee.Base 給定輸入算出輸出,沒有業務語意 AES-CBC-HMAC、RSA、密碼雜湊、金鑰產生器、檔案雜湊
安全政策 Bee.Definition 金鑰從哪裡來、誰能存取、怎麼驗 主金鑰來源、金鑰的保護、存取令牌驗證、第二節那個保護等級

分界只要問一句話:這段程式知不知道它算的是什麼?上一節那支加密程式只認得 bytes 與金鑰,不知道自己在加密一則 API 呼叫,所以它住在原語那一層,也因此本篇的 payload 與接下來要談的金鑰保護用的是同一支程式。

主金鑰是鏈條的第一份

Day 4 那條啟動相依鏈的結論在這裡直接沿用:主金鑰是鏈條的第一份,它自己不能加密,所以 SystemSettings 只記它在哪裡。框架預設的那一份(SystemSettings.xml)記的是一個環境變數名稱:

<MasterKeySource>
  <Type>Environment</Type>
  <Value>BEE_MASTER_KEY</Value>
</MasterKeySource>

環境變數是現行的預設,檔案是另一個選項。兩條路各有各的暴露面,選環境變數不是因為比較安全,是因為容器與 secret 管理工具一律以環境變數注入,走檔案要嘛掛一份 volume、要嘛把檔案烤進映像檔。主金鑰不直接拿來加密任何一則呼叫,它的用途是解開設定檔裡其他的金鑰,那些金鑰存的是密文,包住它們的正是 AesCbcHmacCryptor

傳輸金鑰是逐個 session 一把

Day 13 列過 session 上有哪些屬性,其中一個是這條連線的對稱金鑰,當時就指名了這一篇。它由一個介面提供,而框架給了三種實作:

提供者 一把金鑰怎麼來 撐不撐得住 session 重建
共用式 整個部署共用一把 撐得住
推導式(預設) 由根金鑰與存取令牌推導出來 撐得住
隨機式 登入時隨機產生,只活在 session 裡 撐不住

第三欄決定的是 Day 13 那個從資料庫 seed 重建 session 能不能成立。介面上有一個屬性專門回答它:金鑰救不回來的話,重建出來的 session 會讓使用者看起來還在線上,而每一次加密呼叫都失敗。

推導式的根金鑰預設由主金鑰推出來,設定檔另外指定的話以設定檔為準。選它當預設的代價是同一個存取令牌推出來的永遠是同一把金鑰,不是每次登入都隨機。換到的是這把金鑰不必存、每個節點各自算得出來,而令牌本身是一個猜不到的 GUID、根金鑰不離開伺服器。這一段的順序因此是反直覺的:令牌必須先產生,金鑰才推得出來。

那把金鑰怎麼交到呼叫端手上

登入時呼叫端產生一對 RSA 金鑰,公鑰寫進登入請求;伺服端建好 session 之後,用那把公鑰把對稱金鑰包起來,跟存取令牌一起回傳。之後每一次要求加密的呼叫都用它。

Day 17 寫過瀏覽器那一頭的例外:那裡產不出 RSA 金鑰對,於是公鑰送的是空字串,伺服端回的也是空字串。降級發生在呼叫端而不是伺服端,要求 Encrypted 的呼叫在手上沒有金鑰時自己退到 Encoded。對照第二節那張矩陣就知道這條退路能走多遠:Encoded 這個格式過得了 PublicEncoded 兩級,過不了標了 Encrypted 的那幾支。


回到 Northwind

案例跟這一篇有關的應用程式碼只有開機那幾行:環境變數沒設就塞一把 demo 主金鑰進去,把定義檔裡的 payload 選項建成元件,然後在啟動框架時允許自動建立。其餘全在定義檔裡:壓縮用 gzip、加密用 aes-cbc-hmac、主金鑰來自 BEE_MASTER_KEYvalue 那一格案例沒有另外宣告,走的是預設的 messagepack

[ApiAccessControl] 在整個案例是零處。訂單那個 BO 覆寫了 GetNewData,覆寫的方法上沒有掛宣告,靠的正是第二節那條「找不到就往它覆寫的那一支找」。

不加密的那個實作只允許在偵錯模式下建出來,而案例的偵錯模式是開著的(Day 18 用過同一個旗標,那裡決定的是錯誤訊息要不要被遮蔽),於是那道檢查在這裡形同不存在,案例仍然選了真正的加密。這道檢查本身還有一個缺口:它只擋得住依名字建實作的那一條路,直接把元件交進去的那一條繞得過它。

真正讓案例的加密不算數的不是那道檢查,是那把寫在原始碼裡的 demo 主金鑰。它旁邊的註解自己就寫著 production 必須在啟動之前用真正的部署機制注入一把。框架判別不出自己現在跑在哪一種環境,硬要在啟動時擋下來會誤殺想跑示範資料的測試環境,所以這件事只寫在註解裡。


小結

一次呼叫的 value 上有三段處理與一句宣告:

  • 三段的順序是序列化、壓縮、加密,回程反過來走,而反過來走的意義是每一段只看得到通過上一段檢查的 bytes
  • 保護等級記的是這支方法最低接受什麼,格式記的是這次呼叫實際做了什麼,比的是前者小於等於後者;四個等級裡有三個在同一條軸上,第四個只是借了那條軸來排隊
  • 加密那一段的四個決定都可以逐項驗證:兩把金鑰、每次新的 IV、先加密再蓋認證碼、固定時間比對

這三件全都驗得出來。順序調換過的管線,壓縮率當場就看得出來;等級擋不擋得住,對著端點各打一次就知道;認證碼有沒有蓋在對的位置,改一個位元試一次就有答案。

只有一件不行。那把主金鑰是不是真的只有部署的人知道,從框架裡面看不出來,也量不出來。前面那三件全部做對之後,整套的強度就等於那個環境變數裡放的是什麼,而那件事發生在程式碼之外:真正的防護線是部署的檢查清單。

一條安全管線裡,做得對不對的部分可以被驗證,值不值得信任的部分不能。前者是設計題,寫進機制就結案了;後者每一次部署都要重新回答一次。

明天換一條軸。保護等級管的是一次呼叫的形式,它從頭到尾沒有問過「這個人可以看到哪些資料」。


本系列同步發表於 HackMD,完整目錄


上一篇
Day 22:XML、JSON 與 MessagePack 的分工
下一篇
Day 24:權限的三軸:動作、列與欄
系列文
ERP 架構師筆記:定義驅動的框架設計26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言